3.1. 使用 GDB 进行调试

GDB 可以帮助程序员查找并修复程序中的错误。 GDB 可以处理用多种语言编译的程序,但我们这里主要关注 C。调试器是一个控制另一个程序(正在被调试的程序)执行的程序——它允许程序员看到他们的程序在运行时正在做什么。使用调试器可以帮助程序员发现错误并确定他们发现的错误的原因。以下是 GDB 可以执行的一些有用操作:

  1. 启动一个程序并逐行执行它
  2. 当程序到达代码中的某些点时暂停程序的执行
  3. 根据用户指定的条件暂停程序的执行
  4. 显示程序暂停执行点处的变量值
  5. 暂停后继续执行程序
  6. 检查程序崩溃时的执行状态
  7. 检查调用堆栈上任何堆栈帧的内容

GDB 用户通常会在其程序中设置断点(breakpoints)。断点指定程序中 GDB 将暂停程序执行的点。当正在执行的程序遇到断点时,GDB 暂停执行,并允许用户输入 GDB 命令来检查程序变量和堆栈内容,一次一行地执行程序,添加新断点,然后继续程序的执行直到遇到下一个断点。

许多 Unix 系统还提供数据显示调试器 (Data Display Debugger, DDD),这是一个围绕命令行调试程序(GDB,用于例子)。 DDD 程序接受与 GDB 相同的参数和命令,但它提供了带有调试菜单选项的 GUI 界面以及 GDB 的命令行界面。

在讨论了一些关于如何开始使用 GDB 的预备知识后,我们提供了两个示例 GDB 调试会话,介绍了常用的 GDB 命令查找不同类型错误的上下文。第一个会话(GDB on badprog.c)展示了如何使用 GDB 命令查找 C 程序中的逻辑错误。第二个会话(GDB on segfaulter.c)展示了使用 GDB 命令检查此时的程序执行状态的示例程序崩溃时要找出崩溃的原因。

常用 GDB 命令 部分中,我们更详细地描述了常用的 GDB 命令,并显示了一些命令的更多示例。在后面的部分中,我们将讨论一些高级 GDB 功能。

3.1.1. GDB 入门

调试程序时,使用-g选项对其进行编译很有帮助,该选项会向二进制可执行文件添加额外的调试信息。这些额外信息有助于调试器在二进制可执行文件中查找程序变量和函数,并使其能够将机器代码指令映射到 C 源代码行(C 程序员理解的程序形式)。此外,在编译调试时,请避免编译器优化(例如,不要使用-O2进行构建)。编译器优化的代码通常很难调试,因为优化的机器代码序列通常不能清楚地映射回 C 源代码。尽管我们在以下部分中介绍了-g标志的使用,但某些用户可能会使用-g3标志获得更好的结果,该标志可以显示额外的调试信息。

下面是一个示例gcc命令,它将构建一个合适的可执行文件以使用 GDB 进行调试:

$ gcc -g myprog.c

要启动 GDB,请在可执行文件上调用它。例如:

$ gdb a.out
(gdb)          # the gdb command prompt

当 GDB 启动时,它会打印(gdb)提示符,该提示符允许用户在开始运行a.out程序之前输入 GDB 命令(例如设置断点)。

类似地,要对可执行文件调用 DDD:

$ ddd a.out

有时,当程序因错误而终止时,操作系统会转储一个核心文件,其中包含有关程序崩溃时的状态信息。通过使用核心文件和生成它的可执行文件运行 GDB,可以在 GDB 中检查此核心文件的内容:

$ gdb core a.out
(gdb) where       # the where command shows point of crash

3.1.2. GDB 会话示例

我们通过两个使用 GDB 调试程序的示例会话来演示 GDB 的常见功能。第一个是使用 GDB 查找并修复程序中的两个错误的示例,第二个是使用 GDB 调试崩溃的程序的示例。我们在这两个示例会话中演示的 GDB 命令集包括:

Command描述
break设置断点
run启动程序从头开始运行
cont继续执行程序直到遇到断点
quit退出 GDB 会话
next允许程序执行下一行 C 代码,然后暂停
step允许程序执行下一行C代码;如果下一行包含函数调用,则单步执行该函数并暂停
list列出暂停点或指定点周围的 C 源代码
print打印出程序变量(或表达式)的值
where打印调用栈
frame移至特定栈帧的上下文

使用 GDB 调试程序的示例 (badprog.c)

第一个示例 GDB 会话调试 badprog.c 程序。该程序应该找到int值数组中的最大值。但是,运行时,它错误地发现 17 是数组中的最大值,而不是正确的最大值 60。此示例显示 GDB 如何检查程序的运行时状态以确定程序未计算预期结果的原因。特别是,此示例调试会话揭示了两个错误:

  1. 循环边界错误导致程序访问超出数组边界的元素。
  2. 函数中的错误未向其调用者返回正确的值。

要使用 GDB 检查程序,首先使用“-g”编译程序以将调试信息添加到可执行文件:

$ gcc -g badprog.c

接下来,在二进制可执行程序(a.out)上运行 GDB。 GDB 初始化并打印(gdb)提示,用户可以在其中输入 GDB 命令:

$ gdb ./a.out

GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
  ...
(gdb)

此时GDB还没有开始运行程序。常见的第一个调试步骤是在main()函数中设置断点,以在执行main()中的第一条指令之前暂停程序的执行。 break 命令在指定位置(在本例中是在 main() 函数的开头)设置一个断点(暂停程序):

(gdb) break main

Breakpoint 1 at 0x8048436: file badprog.c, line 36.

run 命令告诉 GDB 启动程序:

(gdb) run
Starting program: ./a.out

如果程序需要命令行参数,请在run命令之后提供它们(例如,run 100 200将使用命令行参数100200运行a.out)。

输入run后,GDB 从头开始​​执行程序,一直运行到遇到断点为止。到达断点时,GDB 在执行断点处的代码行之前暂停程序,并打印出断点编号和与断点相关的源代码行。在此示例中,GDB 在执行程序的第 36 行之前暂停程序。然后它会打印出(gdb)提示并等待进一步的指示:

Breakpoint 1, main (argc=1, argv=0x7fffffffe398) at badprog.c:36
36     int main(int argc, char *argv[]) {

(gdb)

通常,当程序在断点处暂停时,用户希望查看断点周围的 C 源代码。 GDB的list命令显示断点周围的代码:

(gdb) list
29	    }
30	    return 0;
31	}
32
33	/***************************************/
34	int main(int argc, char *argv[]) {
35
36	    int arr[5] = { 17, 21, 44, 2, 60 };
37
38	    int max = arr[0];

对 list 的后续调用将显示这些代码之后的下一行源代码。 list 还可以与特定行号(例如,list 11)或函数名称一起使用,以列出程序指定部分的源代码。例如:

(gdb) list findAndReturnMax
12	 * 	array: array of integer values
13	 * 	len: size of the array
14	 * 	max: set to the largest value in the array
15	 *  	returns: 0 on success and non-zero on an error
16	 */
17	int findAndReturnMax(int *array1, int len, int max) {
18
19	    int i;
20
21	    if (!array1 || (len <=0) ) {

用户可能希望在命中断点后一次执行一行代码,并在执行每一行后检查程序状态。 GDB的next命令仅执行下一行 C 代码。程序执行完这行代码后,GDB再次暂停程序。 print 命令打印程序变量的值。以下是对nextprint的一些调用,以显示它们对接下来两行执行的影响。请注意,next后面列出的源代码行尚未执行——它显示了程序暂停的行,它代表接下来将执行的行:

(gdb) next
36	  int arr[5] = { 17, 21, 44, 2, 60 };
(gdb) next
38	  int max = arr[0];
(gdb) print max
$3 = 0
(gdb) print arr[3]
$4 = 2
(gdb) next
40	  if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) print max
$5 = 17
(gdb)

此时,在程序执行过程中,主函数已初始化其局部变量arrmax,并即将调用findAndReturnMax()函数。 GDB的next命令执行下一个完整的 C 源代码行。如果该行包含函数调用,则该函数调用的完整执行及其返回将作为单个next命令的一部分执行。想要观察函数执行情况的用户应该发出 GDB 的step命令,而不是next命令:step进入函数调用,在执行函数的第一行之前暂停程序。

因为我们怀疑该程序中的错误与findAndReturnMax()函数有关,所以我们希望单步执行该函数而不是跳过它。因此,当在第 40 行暂停时,step命令接下来将在findAndReturnMax()开始处暂停程序(或者,用户可以在findAndReturnMax()处设置一个断点,以在该处暂停程序的执行观点):

(gdb) next
40	  if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) step
findAndReturnMax (array1=0x7fffffffe290, len=5, max=17) at badprog.c:21
21	  if (!array1 || (len <=0) ) {
(gdb)

程序现在暂停在findAndReturnMax函数内,其局部变量和参数现在在范围内。 print命令显示它们的值,list显示暂停点周围的 C 源代码:

(gdb) print array1[0]
$6 = 17
(gdb) print max
$7 = 17
(gdb) list
16	 */
17	int findAndReturnMax(int *array1, int len, int max) {
18
19	    int i;
20
21	    if (!array1 || (len <=0) ) {
22	        return -1;
23	    }
24	    max = array1[0];
25	    for (i=1; i <= len; i++) {
(gdb) list
26	        if(max < array1[i]) {
27	            max = array1[i];
28	        }
29	    }
30	    return 0;
31	}
32
33	/***************************************/
34	int main(int argc, char *argv[]) {
35

因为我们认为存在与该函数相关的错误,所以我们可能需要在该函数内部设置一个断点,以便我们可以在其执行过程中检查运行时状态。特别是,当max更改时在行上设置断点可以帮助我们了解该函数正在做什么。

我们可以在程序中的特定行号(第 27 行)设置断点,并使用cont命令告诉 GDB 让应用程序从暂停点继续执行。只有当程序遇到断点时,GDB才会暂停程序并再次获取控制权,允许用户输入其他GDB命令。

(gdb) break 27
Breakpoint 2 at 0x555555554789: file badprog.c, line 27.

(gdb) cont
Continuing.

Breakpoint 2, findAndReturnMax (array1=0x...e290,len=5,max=17) at badprog.c:27
27	      max = array1[i];
(gdb) print max
$10 = 17
(gdb) print i
$11 = 1

display命令要求 GDB 在每次遇到断点时自动打印出同一组程序变量。例如,每次程序遇到断点时(在findAndReturnMax()循环的每次迭代中),我们都会显示imaxarray1[i]的值:

(gdb) display i
1: i = 1
(gdb) display max
2: max = 17
(gdb) display array1[i]
3: array1[i] = 21

(gdb) cont
Continuing.

Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=21)
    at badprog.c:27
27	      max = array1[i];
1: i = 2
2: max = 21
3: array1[i] = 44

(gdb) cont
Continuing.

Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=21)
    at badprog.c:27
27	      max = array1[i];
1: i = 3
2: max = 44
3: array1[i] = 2

(gdb) cont

Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=44)
    at badprog.c:27
27	      max = array1[i];
1: i = 4
2: max = 44
3: array1[i] = 60

(gdb) cont
Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=60)
    at badprog.c:27
27	      max = array1[i];
1: i = 5
2: max = 60
3: array1[i] = 32767

(gdb)

我们发现了第一个错误! array1[i] 的值为 32767,该值不在传递的数组中,而 i 的值为 5,但 5 不是该数组的有效索引。通过 GDB,我们发现for循环边界需要固定为i < len

此时,我们可以退出 GDB 会话并修复代码中的此错误。要退出 GDB 会话,请输入quit

(gdb) quit
The program is running.  Exit anyway? (y or n) y
$

修复此错误后,重新编译并运行程序,它仍然找不到正确的最大值(它仍然发现 17 是最大值,而不是 60)。根据我们之前的 GDB 运行,我们可能怀疑调用findAndReturnMax()函数或返回时出现错误。我们在 GDB 中重新运行新版本的程序,这次在findAndReturnMax()函数的入口处设置一个断点:

$ gdb ./a.out
...
(gdb) break main
Breakpoint 1 at 0x7c4: file badprog.c, line 36.

(gdb) break findAndReturnMax
Breakpoint 2 at 0x748: file badprog.c, line 21.

(gdb) run
Starting program: ./a.out

Breakpoint 1, main (argc=1, argv=0x7fffffffe398) at badprog.c:36
36	int main(int argc, char *argv[]) {
(gdb) cont
Continuing.

Breakpoint 2, findAndReturnMax (array1=0x7fffffffe290, len=5, max=17)
    at badprog.c:21
21	  if (!array1 || (len <=0) ) {
(gdb)

如果我们怀疑函数的参数或返回值存在错误,检查堆栈的内容可能会有所帮助。 where(或bt,表示"backtrace")GDB 命令打印堆栈的当前状态。在此示例中,main()函数位于栈底部(在第 1 帧中),并在第 40 行执行对findAndReturnMax()的调用。findAndReturnMax()函数位于栈的顶部堆栈(在第 0 帧中),当前暂停在第 21 行:

(gdb) where
#0  findAndReturnMax (array1=0x7fffffffe290, len=5, max=17) at badprog.c:21
#1  0x0000555555554810 in main (argc=1, argv=0x7fffffffe398) at badprog.c:40

GDB 的frame命令移动到栈上任何帧的上下文中。在每个栈帧上下文中,用户可以检查该帧中的局部变量和参数。在此示例中,我们进入堆栈帧 1(调用者的上下文)并打印出main()函数传递给findAndReturnMax()的参数值(例如arrmax) :

(gdb) frame 1
#1  0x0000555555554810 in main (argc=1, argv=0x7fffffffe398) at badprog.c:40
40	  if ( findAndReturnMax(arr, 5, max) != 0 ) {
(gdb) print arr
$1 = {17, 21, 44, 2, 60}
(gdb) print max
$2 = 17
(gdb)

参数值看起来不错,所以让我们检查一下findAndReturnMax()函数的返回值。为此,我们在findAndReturnMax()返回之前添加一个断点,以查看它计算出的max值:

(gdb) break 30
Breakpoint 3 at 0x5555555547ae: file badprog.c, line 30.
(gdb) cont
Continuing.

Breakpoint 3, findAndReturnMax (array1=0x7fffffffe290, len=5, max=60)
    at badprog.c:30
30	  return 0;

(gdb) print max
$3 = 60

这表明该函数已找到正确的最大值 (60)。让我们执行接下来的几行代码,看看main()函数接收到什么值:

(gdb) next
31	}
(gdb) next
main (argc=1, argv=0x7fffffffe398) at badprog.c:44
44	  printf("max value in the array is %d\n", max);

(gdb) where
#0  main (argc=1, argv=0x7fffffffe398) at badprog.c:44

(gdb) print max
$4 = 17

我们发现了第二个错误! findAndReturnMax() 函数识别传递的数组 (60) 中正确的最大值,但它不会将该值返回给 main() 函数。要修复此错误,我们需要更改findAndReturnMax()以返回其值max,或者添加一个“传递指针”(pass-by-pointer)参数,该函数将使用该参数来修改main()的值函数的max局部变量。

使用 GDB 调试崩溃的程序的示例 (segfaulter.c)

第二个示例 GDB 会话(在 segfaulter.c 程序上运行)演示了程序崩溃时 GDB 的行为以及我们如何使用GDB 帮助发现崩溃发生的原因。

在这个例子中,我们只是在 GDB 中运行segfaulter程序并让它崩溃:

$ gcc -g -o segfaulter segfaulter.c
$ gdb ./segfaulter

(gdb) run
Starting program: ./segfaulter

Program received signal SIGSEGV, Segmentation fault.
0x00005555555546f5 in initfunc (array=0x0, len=100) at segfaulter.c:14
14	    array[i] = i;

一旦程序崩溃,GDB 就会在程序崩溃时暂停程序的执行并夺取控制权。 GDB 允许用户在程序崩溃时发出命令来检查程序的运行时状态,通常可以发现程序崩溃的原因以及如何修复崩溃的原因。 GDB的wherelist命令对于确定程序崩溃的位置特别有用:

(gdb) where
#0 0x00005555555546f5 in initfunc (array=0x0, len=100) at segfaulter.c:14
#1 0x00005555555547a0 in main (argc=1, argv=0x7fffffffe378) at segfaulter.c:37

(gdb) list
9	int initfunc(int *array, int len) {
10
11	    int i;
12
13	    for(i=1; i <= len; i++) {
14	        array[i] = i;
15	    }
16	    return 0;
17	}
18

此输出告诉我们程序在第 14 行initfunc()函数中崩溃。检查第 14 行的参数和局部变量的值可能会告诉我们崩溃的原因:

(gdb) print i
$2 = 1
(gdb) print array[i]
Cannot access memory at address 0x4

i 的值看起来不错,但是当我们尝试访问 array 的索引 i 时,我们看到一个错误。让我们打印出array的值(数组基地址的值),看看这是否告诉我们什么:

(gdb) print array
$3 = (int *) 0x0

我们已经找到事故原因了!数组的基地址为零(或NULL),我们知道取消引用空指针(通过array[i])会导致程序崩溃。

让我们看看是否可以通过查看调用者的堆栈帧来找出为什么array参数为NULL

(gdb) frame 1
#1 0x00005555555547a0 in main (argc=1, argv=0x7fffffffe378) at segfaulter.c:37
37	  if(initfunc(arr, 100) != 0 ) {
(gdb) list
32	int main(int argc, char *argv[]) {
33
34	    int *arr = NULL;
35	    int max = 6;
36
37	    if(initfunc(arr, 100) != 0 ) {
38	        printf("init error\n");
39	        exit(1);
40	    }
41
(gdb) print arr
$4 = (int *) 0x0
(gdb)

进入调用者的堆栈帧并打印出main()传递给initfunc()参数的值,表明main()函数向initfunc()函数传递了一个空指针。换句话说,用户忘记在调用initfunc()之前分配arr数组。解决方法是使用malloc()函数为第 34 行的arr分配一些空间。

这两个示例 GDB 会话说明了查找程序中错误的常用命令。在下一节中,我们将更详细地讨论这些命令和其他 GDB 命令。